前言
- 今天是中秋連假第一天,依然要繼續修改未完成的頁面。 今天繼續將
prompt.html
頁面樣式,並且整合先前 index.html
的 Navigation Bar,使風格一致。
prompt.html
頁面設計
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nova Reel – 文字轉影片 Demo</title>
<link rel="stylesheet" href="style.css">
<style>
:root { --bg:#0b0f14; --card:#0f1620; --text:#e6edf3; --muted:#9fb0c3; --primary:#3aa0ff; --danger:#ff5470; --ok:#22c55e; }
html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font:16px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Noto Sans",sans-serif;}
.wrap{max-width:980px;margin:40px auto;padding:24px;}
.card{background:var(--card);border:1px solid #1d2733;border-radius:16px;padding:20px;box-shadow:0 10px 30px rgba(0,0,0,.25)}
h1{margin:0 0 16px;font-size:22px}
p.muted{color:var(--muted);margin-top:8px}
label{display:block;margin:12px 0 6px}
textarea,select,input[type="number"],input[type="text"]{width:100%;background:#0c121a;color:var(--text);border:1px solid #203040;border-radius:12px;padding:12px}
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.btns{display:flex;gap:12px;margin-top:16px}
button{appearance:none;border:0;padding:12px 16px;border-radius:12px;background:var(--primary);color:white;font-weight:600;cursor:pointer}
button.secondary{background:#223347}
button:disabled{opacity:.6;cursor:not-allowed}
.status{margin-top:16px;padding:12px;border-radius:12px;background:#0c121a;border:1px dashed #203040}
progress{width:100%;height:12px}
video{width:100%;max-height:480px;border-radius:12px;background:#000;margin-top:12px}
.kvs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:8px}
.kv{font-size:13px;color:var(--muted)}
a.link{color:#9fd1ff}
.footer{margin-top:16px;color:var(--muted);font-size:13px}
@media (max-width:720px){.row{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="navbar">
<div class="nav-left">
<h2 class="logo">🎬 影片平台</h2>
<a href="index.html" class="nav-link">我的影片</a>
<a href="prompt.html" class="nav-link active">生成影片</a>
</div>
<div class="nav-actions">
<span id="welcomeUser"></span>
<button id="logoutBtn">登出</button>
</div>
</div>
<div class="wrap">
<div class="card">
<h1>Amazon Nova Reel – 文字轉影片</h1>
<p class="muted">在下方輸入你的提示語(prompt),點「開始生成」。此頁會呼叫你在 API Gateway/Lambda 的 <code>POST /reels</code> 與 <code>GET /reels/{job_arn}</code>。
設定 <code>prompt.js</code> 內的 <code>API_BASE</code> 後即可部署到 S3/CloudFront。</p>
<label for="prompt">提示語(Prompt)</label>
<textarea id="prompt" rows="5" placeholder="例:Golden hour drone shot of Taipei skyline, gentle camera dolly-in, cinematic lighting"></textarea>
<div class="row">
<div>
<label for="duration">時長(秒,6 的倍數)</label>
<select id="duration">
<option value="6">6</option>
<option value="12">12</option>
<option value="18">18</option>
<option value="24">24</option>
</select>
</div>
<div>
<label for="dimension">解析度</label>
<select id="dimension">
<option value="1280x720" selected>1280x720(720p)</option>
</select>
</div>
</div>
<div class="row">
<div>
<label for="region">Bedrock 區域</label>
<select id="region">
<option value="ap-northeast-1" selected>ap-northeast-1(Tokyo)</option>
<option value="us-east-1">us-east-1(N. Virginia)</option>
</select>
</div>
<div>
<label for="seed">隨機種子(可空白)</label>
<input id="seed" type="number" placeholder="不填則由後端隨機" />
</div>
</div>
<div class="btns">
<button id="btnStart">開始生成</button>
<button id="btnCancel" class="secondary" disabled>取消輪詢</button>
</div>
<div class="status" id="statusBox" hidden>
<div id="statusText">等待中…</div>
<progress id="progress" max="100" value="5"></progress>
<div class="kvs">
<div class="kv">Job ARN:<span id="jobArn">—</span></div>
<div class="kv">狀態:<span id="jobState">—</span></div>
<div class="kv">輸出:<span id="outLink">—</span></div>
<div class="kv">錯誤:<span id="errMsg">—</span></div>
</div>
<video id="preview" controls playsinline></video>
</div>
<div class="footer">© vlog.nipapa.tw — Demo UI。請先在 <code>prompt.js</code> 設定 <code>API_BASE</code> 與 CORS。</div>
</div>
</div>
<script src="/prompt.js" defer></script>
</body>
</html>
prompt.js
/* =============================
* Nova Reel Frontend – prompt.js
* 必須登入才能使用
* =========================== */
const API_BASE = "https://iwlw3i3ys4.execute-api.ap-northeast-1.amazonaws.com/prod"; // 你的 API Gateway base URL
// 🔑 檢查登入狀態
const token = localStorage.getItem("jwt");
if (!token) {
window.location.href = "/login.html";
}
// 工具
const $ = (id) => document.getElementById(id);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// UI 元件
const promptEl = $("prompt");
const durationEl = $("duration");
const dimensionEl = $("dimension");
const regionEl = $("region");
const seedEl = $("seed");
const statusBox = $("statusBox");
const statusText = $("statusText");
const progressBar = $("progress");
const jobArnEl = $("jobArn");
const jobStateEl = $("jobState");
const outLinkEl = $("outLink");
const errMsgEl = $("errMsg");
const previewEl = $("preview");
const btnStart = $("btnStart");
const btnCancel = $("btnCancel");
let cancelFlag = false;
// Busy 狀態
function setBusy(busy) {
btnStart.disabled = busy;
btnCancel.disabled = !busy;
}
function showStatus(show = true) { statusBox.hidden = !show; }
function setProgress(v) { progressBar.value = Math.min(100, Math.max(0, v)); }
function linkify(url) {
if (!url) return "—";
const a = document.createElement("a");
a.href = url;
a.className = "link";
a.textContent = "下載/播放連結";
a.target = "_blank";
return a.outerHTML;
}
// 建片
async function startJob() {
cancelFlag = false;
setBusy(true);
showStatus(true);
setProgress(5);
statusText.textContent = "建立工作中…";
jobArnEl.textContent = "—";
jobStateEl.textContent = "—";
outLinkEl.innerHTML = "—";
errMsgEl.textContent = "—";
previewEl.removeAttribute("src");
const body = {
prompt: promptEl.value?.trim() || "A cinematic shot of a clear droplet falling into water bowl",
duration_seconds: Number(durationEl.value),
fps: 24,
dimension: dimensionEl.value,
region: regionEl.value,
};
if (seedEl.value) body.seed = Number(seedEl.value);
try {
const resp = await fetch(`${API_BASE}/reels`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify(body),
});
if (resp.status === 401) {
localStorage.removeItem("jwt");
window.location.href = "/login.html";
return;
}
if (!resp.ok) throw new Error(`Create failed: HTTP ${resp.status}`);
const { job_arn, status } = await resp.json();
jobArnEl.textContent = job_arn;
jobStateEl.textContent = status || "InProgress";
statusText.textContent = "已送出,開始輪詢進度…";
await poll(job_arn);
} catch (err) {
console.error(err);
statusText.textContent = "建立工作失敗";
errMsgEl.textContent = String(err.message || err);
} finally {
setBusy(false);
}
}
// 輪詢
async function poll(jobArn) {
setProgress(10);
let tries = 0;
while (!cancelFlag && tries < 120) {
tries++;
try {
const resp = await fetch(`${API_BASE}/reels/${encodeURIComponent(jobArn)}`, {
headers: { "Authorization": "Bearer " + token }
});
if (resp.status === 401) {
localStorage.removeItem("jwt");
window.location.href = "/login.html";
return;
}
if (!resp.ok) throw new Error(`Query failed: HTTP ${resp.status}`);
const data = await resp.json();
jobStateEl.textContent = data.status;
statusText.textContent = `查詢中(第 ${tries} 次)…`;
setProgress(Math.min(95, 10 + tries * (80 / 120)));
if (data.status === "Completed") {
statusText.textContent = "完成!";
setProgress(100);
if (data.presigned_url) {
outLinkEl.innerHTML = linkify(data.presigned_url);
previewEl.src = data.presigned_url;
previewEl.play().catch(()=>{});
} else if (data.s3_uri) {
outLinkEl.textContent = data.s3_uri;
}
return;
}
if (data.status === "Failed") {
setProgress(100);
statusText.textContent = "失敗";
errMsgEl.textContent = data.message || "Unknown";
return;
}
} catch (err) {
console.error(err);
statusText.textContent = "輪詢錯誤,稍後重試…";
errMsgEl.textContent = String(err.message || err);
}
await sleep(8000);
}
if (cancelFlag) {
statusText.textContent = "已取消輪詢";
} else {
statusText.textContent = "超過最大輪詢次數,請稍後再查";
}
}
btnStart.addEventListener("click", startJob);
btnCancel.addEventListener("click", () => { cancelFlag = true; setBusy(false); });
結論
- 完成頁面轉接 API 設定了,也可以根據
username
將生成出的影片放置在對應的 S3 路徑。
- 接下來要檢查的部分,是針對各段程式碼及 API 的提取內容路徑進行檢查,避免去偷撈別人資料的狀況發生。